Skip to content

Use asyncio - #2880

Open
rwols wants to merge 249 commits into
mainfrom
feat/asyncio
Open

Use asyncio#2880
rwols wants to merge 249 commits into
mainfrom
feat/asyncio

Conversation

@rwols

@rwols rwols commented Apr 22, 2026

Copy link
Copy Markdown
Member

This PR switches the codebase to using async def functions and asyncio. The loop provider is sublime_aio.

close #2863.

should be merged (and released) at the same time as:

The main driver for doing this is to decrease the thread usage of this plugin from O(n) to O(1) threads, where n is the number of language servers running. The secondary driver is syntax sugar.

Why is this PR so large? Please read: What color is your function?

Self-contained bits:

Topic Old Way New Way
Running a short function on a thread that's not the main thread sublime.set_timeout_async LSP.plugin.core.aio.call_soon_threadsafe
Defining a function that's asynchronous def f() -> Promise[T]: ... async def f() -> T: ...
Chaining asynchronous functions Promise.then(lambda x: ...) x = await f()
Doing something when a server request is done session.send_request_async(R(), lambda x: ...) x = await session.request(R())
Doing something when a server request fails define an on_error callback use a try ... except ResponseException: block
Handling partial request results define an on_partial_result callback async for partial_result in session.stream(R()): (caveat: only works for list[...]-style responses)
Starting a coroutine from a regular function n/a LSP.plugin.core.aio.run_coroutine_threadsafe(f())
Awaiting old-style Promise objects Promise.then await promise
Waiting for all asynchronous operations to complete Promise.all asyncio.gather
Doing something later sublime.set_timeout_async(f, timeout_ms=1000) await asyncio.sleep(1)
Enforcing a critical section use threading.Lock, or write very complicated queueing logic use asyncio.Lock
Wrapping a new async function in a Promise n/a Promise.wrap_task
f calls g async g blocking g
async f async def f(): await g() async def f(): g()
blocking f, guaranteed called from asyncio thread use aio.TaskContainer.create_task(g()) def f(): g()
blocking f, any thread def f(): aio.run_coroutine_threadsafe(g()), or use aio.TaskContainer.create_task_threadsafe(g()) def f(): g()

The Plan

Make "most" code run on the sublime_aio thread

Most code is doing bookkeeping. This type of code used to run on the Sublime "async" thread. It should run on the asyncio loop thread.

Previously, the code attempted to make most code run on the ST async thread. We never really enforced this. We tried to make it clear that a function/method should be running on the ST async thread by suffixing it with _async.

If you have an async def coroutine function, then such a coroutine function is forced to run on the asyncio loop thread. So enforcement becomes automatic.

Keep _async suffixes, assume they run on the asyncio thread

When a method or function has the suffix _async in its name, we tried to ensure these functions run on the ST async thread. These can now be assumed to be running on the asyncio thread.

Make compute-intensive function run on the Sublime "async" thread

The only compute-intensive code we deal with are parsing and emitting JSON. Only the JSON parser/emitter should run on the ST async thread.

Bridging code for existing LSP-* plugins

We made sure that all AbstractPlugin and LspPlugin related (class)methods ran on the ST async thread. I want to now make sure all these (class)methods run on the sublime_aio thread with this pull request.

Certain methods may also be marked async for LspPlugin, most notably on_pre_start and perhaps on_initialize.

The Promise object can be awaited, so older AbstractPlugin/LspPlugin-related functionality returning promises from request handlers work.

rwols added 13 commits April 13, 2026 23:50
Add a test that checks that "tcp server mode" works. Server meaning
that this plugin acts as the TCP server and the langserver connects as
TCP client.
Add a test that checks that "tcp server mode" works. Server meaning
that this plugin acts as the TCP server and the langserver connects as
TCP client.
Conflicts:
	tests/server.py
@netlify

netlify Bot commented Apr 22, 2026

Copy link
Copy Markdown

Deploy Preview for sublime-lsp ready!

Name Link
🔨 Latest commit ea8cee1
🔍 Latest deploy log https://app.netlify.com/projects/sublime-lsp/deploys/6a61dae39934e3000816347f
😎 Deploy Preview https://deploy-preview-2880--sublime-lsp.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

Comment thread tests/server.py Outdated
@predragnikolic

This comment was marked as resolved.

@rwols

This comment was marked as outdated.

Also write the rest of the requests in terms of Session.request
@rchl

rchl commented Apr 22, 2026

Copy link
Copy Markdown
Member

Would be useful to split it into smaller chunks, if possible. For example it would likely be possible to make start_async async by making it return a Promise and then later convert it to asyncio easily.

Lots of assumptions on my side but that's what I feel.

I was actually looking into that before as I wanted to move start_async into dedicated thread so that it doesn't black other plugins (kinda opposite goal of yours but also kinda similar as I guess with asyncio it will also run on dedicated thread). See #2863

It will be hard to review it properly with a big dump of code that refactors most of the code base.

Comment thread plugin/core/promise.py Outdated
Comment thread plugin/core/windows.py Outdated
Comment thread plugin/core/windows.py Outdated
rwols added 5 commits April 28, 2026 18:53
- Add sublime.set_timeout executor wrapper
- Make all request handlers `async`
- Define a CancellableInflightStreamingRequest class that enables `async for` syntax
- Start inheriting DocumentSyncListener from sublime_aio.ViewEventListener
  (This one doesn't work yet)

The state is fairly broken at this point.
Comment thread plugin/core/sessions.py Outdated
Comment thread plugin/completion.py Outdated
Comment thread plugin/completion.py
Comment thread plugin/completion.py
Comment thread plugin/completion.py Outdated
Comment thread plugin/core/sessions.py Outdated
Comment thread plugin/core/sessions.py Outdated
@rwols
rwols requested a review from rchl August 20, 2026 06:18

@rchl rchl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have this reproducible crash that ends up breaking other stuff.

  1. I'm working on python files on which I have basedpyright and ruff running.
  2. I open my test HelloWorld.java file which triggers initialization of LSP-jdtls
  3. For one reason or another jdtls fails to start on my system. LSP prints to the console:

LSP: jdtls crashed (1 / 5 times in the last 180.0 seconds), exit code 13, exception: None

  1. Now on opening new python files basedpyright and ruff don't start on those files anymore.

The example java file itself has this code:

public class HelloWorld {
    // Your program begins with a call to main()
    public static void main(String[] args) {
        // Prints "Hello, World" to the terminal window.
        System.out.println("Hello, World");
    }
}

On main it tries to start 5 times and each time prints more useful error message (server's output):

LSP: jdtls crashed (1 / 5 times in the last 180.0 seconds), exit code 0, exception: Unexpected payload in server's stdout:


An error has occurred. See the log file
/Users/rafal/Library/Caches/Sublime Text/Package Storage/LSP-jdtls/data/.metadata/.log.

LSP: jdtls crashed (2 / 5 times in the last 180.0 seconds), exit code 0, exception: Unexpected payload in server's stdout:


An error has occurred. See the log file
/Users/rafal/Library/Caches/Sublime Text/Package Storage/LSP-jdtls/data/.metadata/.log.

...

"""Holds state per request."""

def __init__(self, sv: SessionViewProtocol, request_id: int, request: Request[Any, Any]) -> None:
def __init__(self, sv: SessionViewProtocol, cancellable: RequestController, request: Request[Any, Any]) -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's rename cancellable to controller

Comment thread plugin/core/aio.py


def run_coroutine(
coroutine: Coroutine[object, object, T], *, exception_policy: ExceptionPolicy = ExceptionPolicy.STACKTRACE

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like exception_policy is never used so why introduce it now?

Even if we think of potential use for it later, I don't think that ExceptionPolicy.MESSAGEBOX would be that useful. More likely someone would want to show some custom error rather than just dump the exception in the message box.

Comment thread plugin/core/aio.py
return _run_on_st_thread(sublime.set_timeout_async, f, *args, **kwargs)


def tick(n: int = 1) -> asyncio.Future[None]:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have hard time thinking of a use case for being able to specify the exact number of ticks. Can we remove that (not currently used) feature?

Comment thread plugin/core/aio.py
flattened list of Exceptions that occurred for each coroutine. BaseExceptions are filtered out.
"""
exceptions: list[Exception] = []
items: list[BaseException | list[Exception]] = await asyncio.gather(*coros, return_exceptions=True)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant annotation

Suggested change
items: list[BaseException | list[Exception]] = await asyncio.gather(*coros, return_exceptions=True)
items = await asyncio.gather(*coros, return_exceptions=True)

Comment thread plugin/core/edit.py

def show_summary_message(
window: sublime.Window, result: ApplyWorkspaceEditResult, summary: WorkspaceEditSummary
window: sublime.Window, result: ApplyWorkspaceEditResult, summary: WorkspaceEditSummary | BaseException

@rchl rchl Aug 22, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it still hold that the summary can be a BaseException? At least the types at call sites suggest that it can't.

Comment thread plugin/core/open.py
Comment on lines +63 to +68
view = await open_file(window, decoded_uri, flags, group)
if view:
return center_selection(view, r)
return None
if fragment := urlparse(decoded_uri).fragment:
if selection := lsp_range_from_uri_fragment(fragment):
center_selection(view, selection)
return view

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

less lines

    if view and (fragment := urlparse(decoded_uri).fragment) and (selection := lsp_range_from_uri_fragment(fragment)):
        center_selection(view, selection)

Comment thread plugin/core/open.py
Comment on lines +116 to +142
def on_main_thread() -> None:

# window.open_file brings the file to focus if it's already opened, which we don't want (unless it's
# supposed to open as a separate view).
view = _find_open_file(window, file)
if view and _return_existing_view(flags, window.get_view_index(view)[0], window.active_group(), group):
loop.call_soon_threadsafe(lambda: resolve_right_now(view))
return

was_already_open = view is not None
if not was_already_open and not os.path.isfile(file):
# window.open_file creates a new view with empty content if the path from the given URI doesn't
# exist as a file on disk, but we don't want that here. If the language server wants to create a new
# file for a given URI, it must use the CreateFile resource operation in a WorkspaceEdit.
loop.call_soon_threadsafe(lambda: resolve_right_now(view))
return

view = window.open_file(file, flags, group)
if not view.is_loading():
if was_already_open and (flags & sublime.NewFileFlags.SEMI_TRANSIENT):
# workaround bug https://github.com/sublimehq/sublime_text/issues/2411 where transient view
# might not get its view listeners initialized.
sublime_plugin.check_view_event_listeners(view) # type: ignore
# It's already loaded. Possibly already open in a tab.
loop.call_soon_threadsafe(lambda: resolve_right_now(view))

loop.call_soon_threadsafe(resolve_later)

@rchl rchl Aug 22, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our convention is not to have blank lines in function bodies:

Suggested change
def on_main_thread() -> None:
# window.open_file brings the file to focus if it's already opened, which we don't want (unless it's
# supposed to open as a separate view).
view = _find_open_file(window, file)
if view and _return_existing_view(flags, window.get_view_index(view)[0], window.active_group(), group):
loop.call_soon_threadsafe(lambda: resolve_right_now(view))
return
was_already_open = view is not None
if not was_already_open and not os.path.isfile(file):
# window.open_file creates a new view with empty content if the path from the given URI doesn't
# exist as a file on disk, but we don't want that here. If the language server wants to create a new
# file for a given URI, it must use the CreateFile resource operation in a WorkspaceEdit.
loop.call_soon_threadsafe(lambda: resolve_right_now(view))
return
view = window.open_file(file, flags, group)
if not view.is_loading():
if was_already_open and (flags & sublime.NewFileFlags.SEMI_TRANSIENT):
# workaround bug https://github.com/sublimehq/sublime_text/issues/2411 where transient view
# might not get its view listeners initialized.
sublime_plugin.check_view_event_listeners(view) # type: ignore
# It's already loaded. Possibly already open in a tab.
loop.call_soon_threadsafe(lambda: resolve_right_now(view))
loop.call_soon_threadsafe(resolve_later)
def on_main_thread() -> None:
# window.open_file brings the file to focus if it's already opened, which we don't want (unless it's
# supposed to open as a separate view).
view = _find_open_file(window, file)
if view and _return_existing_view(flags, window.get_view_index(view)[0], window.active_group(), group):
loop.call_soon_threadsafe(lambda: resolve_right_now(view))
return
was_already_open = view is not None
if not was_already_open and not os.path.isfile(file):
# window.open_file creates a new view with empty content if the path from the given URI doesn't
# exist as a file on disk, but we don't want that here. If the language server wants to create a new
# file for a given URI, it must use the CreateFile resource operation in a WorkspaceEdit.
loop.call_soon_threadsafe(lambda: resolve_right_now(view))
return
view = window.open_file(file, flags, group)
if not view.is_loading():
if was_already_open and (flags & sublime.NewFileFlags.SEMI_TRANSIENT):
# workaround bug https://github.com/sublimehq/sublime_text/issues/2411 where transient view
# might not get its view listeners initialized.
sublime_plugin.check_view_event_listeners(view) # type: ignore
# It's already loaded. Possibly already open in a tab.
loop.call_soon_threadsafe(lambda: resolve_right_now(view))
loop.call_soon_threadsafe(resolve_later)

Comment thread plugin/core/open.py
# It's already loaded. Possibly already open in a tab.
loop.call_soon_threadsafe(lambda: resolve_right_now(view))

loop.call_soon_threadsafe(resolve_later)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing return here, I think.

Comment thread plugin/session_buffer.py
Comment on lines +1028 to 1029
@deprecated("use SessionBuffer.request_code_actions instead")
def request_code_actions_async(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this actually used anywhere? I can't find any references. Not even in packages.

Comment thread plugin/core/sessions.py
Comment on lines +1597 to +1598
@deprecated("use Session.run_code_action instead")
def run_code_action_async(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can be removed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Server installation can block other plugins

5 participants